Skip to content

xdsresolver: use the refcounted utility for cluster refcounting - #9318

Open
ulascansenturk wants to merge 4 commits into
grpc:masterfrom
ulascansenturk:xdsresolver-refcounted
Open

xdsresolver: use the refcounted utility for cluster refcounting#9318
ulascansenturk wants to merge 4 commits into
grpc:masterfrom
ulascansenturk:xdsresolver-refcounted

Conversation

@ulascansenturk

Copy link
Copy Markdown
Contributor

Replaces the hand-rolled atomic.Int32 in clusterInfo with grpcsync.RefCounted, as suggested in #9304.

The "decrement, and if the count hit zero run the cleanup" logic was duplicated at three sites (SelectConfig's OnCommitted, configSelector.stop, and pruneActiveClustersAndPlugins). It now lives in a single onZero callback registered when the entry is created, which lets SelectConfig's cluster and plugin branches collapse into one path.

Entries now remove themselves from activeClusters/activePlugins once their last reference is released, so pruneActiveClustersAndPlugins is gone.

Two details worth a reviewer's attention:

  • The map removal is scheduled on the serializer, because the last reference is usually released by an RPC completing on an arbitrary goroutine while the active maps may only be touched from a serializer callback. It is guarded by an identity check so that a pending removal cannot evict a newer entry that has since taken the same key, and acquisition uses TryIncrement so a dead entry is replaced rather than revived.

  • For clusters, the removal is queued before unsubscribe() is called, and unsubscribe() itself stays synchronous. Queueing first matters because unsubscribing makes the dependency manager push an update onto the same serializer; if the removal were queued after, that update would regenerate a service config still containing the dropped cluster (TestResolverKeepWatchOpen_ActiveRPCs catches this). Keeping unsubscribe() out of the scheduled callback matters because that callback returns early when a newer entry has taken the key, which would otherwise leak the old subscription.

References for a config selector are now taken as each cluster is recorded, rather than in a batch after all routes are built. This keeps acquisition and release symmetric, so a config selector that fails partway through releases exactly what it acquired.

TestPruneActiveClusters is replaced by two tests covering entry reuse/release and the no-revival guard.

Fixes #9304

RELEASE NOTES: none

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.67442% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.56%. Comparing base (6d697e4) to head (32adcb0).

Files with missing lines Patch % Lines
internal/xds/resolver/xds_resolver.go 97.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9318      +/-   ##
==========================================
+ Coverage   87.54%   87.56%   +0.02%     
==========================================
  Files         429      429              
  Lines       30622    30618       -4     
==========================================
+ Hits        26807    26810       +3     
+ Misses       3814     3807       -7     
  Partials        1        1              
Files with missing lines Coverage Δ
internal/xds/resolver/serviceconfig.go 91.74% <100.00%> (+0.07%) ⬆️
internal/xds/resolver/xds_resolver.go 90.45% <97.14%> (-0.92%) ⬇️

... and 25 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ulascansenturk
ulascansenturk force-pushed the xdsresolver-refcounted branch from 105fcbc to c8266c9 Compare August 13, 2026 19:32
@easwars easwars added Type: Internal Cleanup Refactors, etc Area: xDS Includes everything xDS related, including LB policies used with xDS. labels Aug 13, 2026
@easwars easwars added this to the 1.84 Release milestone Aug 13, 2026
@ulascansenturk
ulascansenturk force-pushed the xdsresolver-refcounted branch from c8266c9 to a8b9135 Compare August 13, 2026 19:43
Comment thread internal/xds/resolver/xds_resolver.go
Comment thread internal/xds/resolver/xds_resolver.go
Comment thread internal/xds/resolver/xds_resolver.go
Replace the hand-rolled atomic.Int32 in clusterInfo with
grpcsync.RefCounted. The "decrement, and if the count hit zero run the
cleanup" logic was duplicated at three sites; it now lives in a single
onZero callback registered when the entry is created.

Entries remove themselves from activeClusters/activePlugins when their
last reference is released, so pruneActiveClustersAndPlugins is no longer
needed. Removal is scheduled on the serializer, since the last reference
is usually released by an RPC completing on an arbitrary goroutine, and
is guarded by an identity check so a pending removal cannot evict a newer
entry that has since taken the same key.

References for a config selector are now taken as each cluster is
recorded rather than in a batch after all routes are built, so a config
selector that fails partway through releases exactly what it acquired.

Each routeCluster now holds the refcounted entry it refers to. Config
selection no longer looks the entry up by name in cs.clusters/cs.plugins
on the RPC path, which also removes the unreachable panic for a matched
cluster missing from both maps.

RELEASE NOTES: none
@ulascansenturk
ulascansenturk force-pushed the xdsresolver-refcounted branch from a8b9135 to 9c11b34 Compare August 14, 2026 11:12
@eshitachandwani

Copy link
Copy Markdown
Member

Hey @ulascansenturk, could you please reply to the comments once you've addressed them? Also, when updating the PR, please push your new changes as separate commits rather than force-pushing to squash them. Keeping the commit history intact makes it much easier for us to review what has changed. Thanks!

@@ -1385,6 +1385,95 @@ func (s) TestResolverKeepWatchOpen_ActiveRPCs(t *testing.T) {
// TestResolver_XDSConfigInRPCContext verifies that the xDS resolver's config

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This the comment has been seperated from the test , please fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The doc comment for TestResolver_XDSConfigInRPCContext had been left above my test, so both were orphaned. Moved it back onto its own function.

// single reference per distinct cluster, however many routes name it, and
// releases exactly that one reference when it is stopped.
//
// If a reference were taken per route instead, the counts would not balance:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this paragraph. We is being done and tested should be enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

// TestActivePluginRefCounting verifies that repeated acquisitions of a cluster
// specifier plugin share a single entry, and that the entry is removed from
// activePlugins only once the last reference is released.
func (s) TestActivePluginRefCounting(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THis can easily be converted to an E2E test where we send RPCs on a plugin and make sure it is not removed from the service config when the RPCs are in flight. ANd is removed once the RPCs are done and it is no longer part of the new XDSConfig.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped this test entirely. TestXDSResolverDelayedOnCommittedCSP already covers that scenario end to end: it holds an RPC on cspA, switches the route to cspB, checks both appear in the service config, then commits the RPC and checks cspA is gone. Adding another e2e test for it would just duplicate that one.

// count has already dropped to zero is replaced by a fresh entry rather than
// resurrected, and that the pending removal of the dead entry does not delete
// its replacement.
func (s) TestActivePluginNotRevivedAfterRelease(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also try to turn this into an e2e test? We might not be able to test the exact same thing but we should be able to test the concurrency , something like if last RPC ref is committed in a go routine and we send the xdsConfig update at the same time, whichever happens first should be handled correctly. WDYT ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, added TestResolverClusterSpecifierPluginRefCountRace in cluster_specifier_plugin_test.go and removed the unit test.

It holds an RPC on cspA, moves the route to cspB so cspA is referenced only by that RPC, then commits the RPC in a goroutine while pushing an update that names cspA again. Both orderings have to converge on cspA being in the service config and usable for new RPCs: if the commit wins, the entry is torn down and TryIncrement fails so a fresh one replaces it, and if the update wins the existing entry is reused. Passes with -race at -count=10.

I also added a waitForServiceConfig helper, since the resolver publishes intermediate configs here and verifyUpdateFromResolver only looks at the next update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up: the first version of this test was flaky and CI caught it. It pushed the cspB update and the cspA update back to back without waiting in between, so on a loaded machine the client could be scheduled only after both snapshots had landed. The route config it then saw was identical to the original cspA one, so the resolver had nothing new to publish and the test timed out. Reproducible locally with -cpu 1.

It now waits for the intermediate config naming both plugins before starting the race, which confirms the client processed cspB first. Passing at -count=20 with -cpu 1, and with -race at -cpu 1, 2 and 4.

@ulascansenturk

Copy link
Copy Markdown
Contributor Author

Heads up on the red upload check, it is not coming from this PR. The coverage workflow pins go-version: stable, which now resolves to go1.27.0, while the test matrix pins 1.25 and 1.26. Go 1.27 changed compress/flate output, so the gzip sizes hardcoded in the stats/opentelemetry tests no longer match. e2e_test.go:448 expects 57 and gets 54. Checking it directly, gzip of 10000 zero bytes goes from 48 bytes on 1.25 and 1.26 to 45 bytes on 1.27, the same 3 byte delta.

The identical failure is on #9343 and a couple of other open PRs, and master's own codecov runs were green through the 19th, which lines up with when stable flipped to 1.27. Every test job on this PR is passing, including the race one.

I could not find an existing issue for it. I am happy to open one and fix it if nobody is already on it. It looks like the hardcoded sizes in the otel and csm tests want computing at runtime rather than asserting a literal, otherwise it breaks again on the next flate change.

@eshitachandwani

Copy link
Copy Markdown
Member

Heads up on the red upload check, it is not coming from this PR. The coverage workflow pins go-version: stable, which now resolves to go1.27.0, while the test matrix pins 1.25 and 1.26. Go 1.27 changed compress/flate output, so the gzip sizes hardcoded in the stats/opentelemetry tests no longer match. e2e_test.go:448 expects 57 and gets 54. Checking it directly, gzip of 10000 zero bytes goes from 48 bytes on 1.25 and 1.26 to 45 bytes on 1.27, the same 3 byte delta.

The identical failure is on #9343 and a couple of other open PRs, and master's own codecov runs were green through the 19th, which lines up with when stable flipped to 1.27. Every test job on this PR is passing, including the race one.

I could not find an existing issue for it. I am happy to open one and fix it if nobody is already on it. It looks like the hardcoded sizes in the otel and csm tests want computing at runtime rather than asserting a literal, otherwise it breaks again on the next flate change.

Hey , thank you for investigating this, we are already in discussion about the best way to fix this.

@github-actions

Copy link
Copy Markdown

This PR is labeled as requiring an update from the reporter, and no update has been received after 6 days. If no update is provided in the next 7 days, this issue will be automatically closed.

@eshitachandwani

Copy link
Copy Markdown
Member

Hey @ulascansenturk , can you rebase it on master to get the codecov job passing ?

@ulascansenturk

Copy link
Copy Markdown
Contributor Author

Done, merged master in rather than rebasing so the existing commits stay intact, as you asked earlier. That picks up #9346, and the otel and csm packages now pass locally under the same -coverprofile -coverpkg=./... the codecov job uses.

Also reran the full ./internal/xds/... ./xds/... ./test/xds/... with -race after the merge and it is green.

})
}

type clusterInfo struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this only has one field, can we remove the struct and just use xdsChildConfig instead ?

// info is the resolver-wide entry for this cluster, shared by every route
// that references it. An RPC routed here holds a reference on it until the
// RPC is committed.
info *grpcsync.RefCounted[*clusterInfo]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to store it here ? We can retrieve it from the configSelector struct itself , similar to what was being done earlier. Storing it in different places increases risk and overhead of making sure they are all synced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: xDS Includes everything xDS related, including LB policies used with xDS. Type: Internal Cleanup Refactors, etc

Projects

None yet

Development

Successfully merging this pull request may close these issues.

xdsresolver: change refcounting for the cluster to use refcounted utility

3 participants